Form 组件的问题及 useForm 优化策略
概述
本节分析自定义 Form 组件中存在的双层响应式问题,以及 useForm 组合式函数的优化策略。核心问题在于:Form 组件内部维护了一个本地 model 响应式对象绑定到 Element Plus 表单,同时外部也通过 v-model 传入响应式数据。这两个响应式对象之间的同步需要谨慎处理,避免循环更新。
问题分析
双层响应式对象结构
外部组件
└── v-model="formData" ← 外层响应式对象
└── <VpForm :schema="schema" v-model="formData" />
└── 内部 localModel ← 内层响应式对象
└── <el-form :model="localModel">
└── <el-input v-model="localModel.prop" />
text
| 响应式层 | 位置 | 用途 |
|---|---|---|
| 外层 model(formData) | 父组件 | 业务数据源,控制表单初始值 |
| 内层 localModel | Form 组件内部 | 绑定 Element Plus <el-form :model> |
存在的问题
- 双向同步冲突:内部 input 变更会修改 localModel,需要同步到外层 model;外层 model 变更也需要同步到 localModel
- 循环更新风险:外层变更 → 更新内层 → 触发 watch → 又更新外层
- 初始化时机:
beforeMount中对 model 进行初始化,但 schema 可能还未就绪
数据流分析
方案一(当前):
schema.value → beforeMount → 初始化 localModel → 绑定 el-form
localModel 变更 → watch → 同步到外层 model
外层 model 变更 → watch → 同步到 localModel(循环风险!)
方案二(优化后):
外层 model 变更 → nextTick → 一次性更新 localModel(单向)
schema 变更 → nextTick → 一次性更新 localModel(单向)
localModel 变更 → emit('update:modelValue') → 通知外层
text
优化策略
策略一:单向数据流 + nextTick 防抖
// composables/useForm.ts
import { ref, watch, nextTick, type Ref } from 'vue'
interface UseFormOptions {
schema: Ref<FormSchema[]>
modelValue: Ref<Record<string, any>>
}
export function useForm(options: UseFormOptions) {
const { schema, modelValue } = options
const localModel = ref<Record<string, any>>({})
// 标记:是否为内部更新(防止循环)
let isInternalUpdate = false
// 外层 model → 内层 localModel(一次性更新)
watch(
() => modelValue.value,
(newModel) => {
if (isInternalUpdate) return
nextTick(() => {
localModel.value = { ...newModel }
})
},
{ deep: true, immediate: true }
)
// schema 变更 → 重新初始化 localModel
watch(
() => schema.value,
(newSchema) => {
nextTick(() => {
const initModel: Record<string, any> = {}
newSchema.forEach((item) => {
initModel[item.prop] = item.value
})
localModel.value = { ...localModel.value, ...initModel }
})
}
)
// 内层 localModel → 通知外层
watch(
() => localModel.value,
(newLocal) => {
isInternalUpdate = true
// emit('update:modelValue', { ...newLocal })
nextTick(() => {
isInternalUpdate = false
})
},
{ deep: true }
)
return { localModel }
}
typescript
策略二:合并为单一数据源
// 不维护内部 localModel,直接使用外层 model
// 通过 schema 初始化外层 model,不再创建中间层
export function useFormSimple(options: UseFormOptions) {
const { schema, modelValue } = options
// schema 变更时直接更新外层 model
watch(
() => schema.value,
(newSchema) => {
const initModel: Record<string, any> = { ...modelValue.value }
newSchema.forEach((item) => {
if (!(item.prop in initModel)) {
initModel[item.prop] = item.value
}
})
modelValue.value = initModel
},
{ immediate: true }
)
// 直接使用 modelValue 绑定 el-form
return { model: modelValue }
}
typescript
策略三:使用 shallowRef + 手动触发
// 使用 shallowRef 避免深层响应式,减少不必要的触发
import { shallowRef, triggerRef } from 'vue'
export function useFormShallow(options: UseFormOptions) {
const localModel = shallowRef<Record<string, any>>({})
function updateModel(newValues: Record<string, any>) {
localModel.value = { ...localModel.value, ...newValues }
triggerRef(localModel) // 手动触发响应式更新
}
return { localModel, updateModel }
}
typescript
三种策略对比
| 维度 | 策略一:单向+防抖 | 策略二:单一数据源 | 策略三:shallowRef |
|---|---|---|---|
| 复杂度 | 中等 | 低 | 中等 |
| 循环更新 | 通过 flag 避免 | 不存在 | 不存在 |
| 兼容性 | 需要维护 flag | 需要外层配合 | 需要手动 triggerRef |
| 推荐场景 | 通用场景 | 简单表单 | 性能敏感场景 |
核心原则
- 外层 model 更新时:只允许一次性同步到内层 localModel
- schema 变更时:只允许一次性更新 localModel
- 避免循环:使用
isInternalUpdateflag 或nextTick控制同步方向
实践要点
- Form 组件存在双层响应式对象(外层 model + 内层 localModel),需要避免循环更新
- 使用
isInternalUpdateflag 标记内部更新,防止 watch 循环触发 - schema 变更时应一次性更新 localModel,而非逐字段触发
- 推荐策略二(单一数据源),移除中间层,由外层直接控制 model
nextTick确保在 DOM 更新后再同步,避免时序问题
↑